「有寫測試」跟「測得夠不夠」是兩回事。今天把 omnipay-ecpay 全部 21 個測試方法攤開來數一遍,看清楚哪裡是真的紮實、哪裡只是「至少測過一次」而已。
testInvalidCheckMacValue 這個案例在防什麼| 測試類別 | 測試方法數 | 涵蓋情境 |
|---|---|---|
PurchaseRequestTest |
5 | 信用卡 / ATM / BNPL 無卡分期 / 信用卡彈性分期,共 4 種付款方式 + 一個組資料的基礎案例 |
GatewayTest |
6(另外自動繼承 26 個泛用測試,見下方說明) | Gateway 層對 6 個方法(purchase/completePurchase/acceptNotification/fetchTransaction/refund/void)各委派一次 |
AcceptNotificationRequestTest |
3 | 正常通知 + 委派後的欄位轉換 + 簽章驗證失敗 |
CompletePurchaseRequestTest |
3 | 正常回傳 + 委派後的欄位轉換 + 簽章驗證失敗 |
RefundRequestTest |
1 | 只驗證 Action 欄位固定回傳 'R' |
VoidRequestTest |
1 | 只驗證 Action 欄位固定回傳 'N' |
光看這張表就能發現落差:PurchaseRequest 一個類別就佔了 5 個測試方法,涵蓋 4 種付款情境;RefundRequest、VoidRequest 各自只有 1 個測試,而且都只驗證同一件事——送出的資料裡 Action 欄位有沒有帶對值,完全沒有驗證「金額是 0 怎麼辦」「退款失敗會怎麼樣」這類邊界或例外情境。
// RefundRequestTest.php 唯一的測試方法
public function testGetData()
{
$request = new RefundRequest($this->getHttpClient(), $this->getHttpRequest());
$request->initialize([/* ... */]);
$data = $request->getData();
self::assertEquals('R', $data['Action']);
self::assertEquals('1000', $data['TotalAmount']);
}
補充一點:GatewayTest 之所以在表格裡看起來只有 6 個,是因為它繼承了 Omnipay 官方測試框架(omnipay/tests)提供的 GatewayTestCase 基底類別,實際本地跑 phpunit --list-tests 會發現 GatewayTest 底下總共執行 32 個測試方法——6 個是套件自己寫的,另外 26 個(testSupportsPurchase、testDefaultParametersHaveMatchingMethods 這類)是基底類別透過反射自動產生的泛用測試,任何 Omnipay 驅動套件只要繼承這個基底類別,都不用自己動手就先擋住一整層最基本的介面規範。全套件實際執行的測試數字是 47 個(21 個自己寫的 + 26 個繼承來的),比只算自己寫的 21 個更能反映這個套件真正被驗證過的範圍。
同一個套件裡,兩種程式碼受到的測試保護程度可以差這麼多——不是因為 Refund/Void 比較不重要(退款失敗對使用者的傷害通常比訂單查詢更直接),而是因為它們沒有像 Purchase 那樣,隨著多種付款方式的需求一直被回頭補測試。
如果只能挑一個地方說「這裡的測試真的做對了」,是 AcceptNotificationRequestTest 跟 CompletePurchaseRequestTest 裡的 testInvalidCheckMacValue。
綠界在付款完成後,會用背景通知(Server 對 Server)告訴你的系統「這筆訂單付款成功了」,通知資料裡會帶一個 CheckMacValue——用你的 HashKey/HashIV 對其他欄位算出來的簽章。如果有人想偽造一筆「付款成功」的假通知,因為不知道你的 HashKey/HashIV,算不出正確的 CheckMacValue,理論上會被擋下來。這個套件確實測了這件事:
public function testInvalidCheckMacValue()
{
$this->expectException(InvalidRequestException::class);
$this->expectExceptionMessage('CheckMacValue verify fail');
$data = [
// ...其餘欄位跟正常通知一模一樣
'CheckMacValue' => '7EC8DDC6C5C51B1A4D8BEA261246066858B38184C55FD3DD3D6DFF53F535A64',
// 正確值應該是 E7EC8DDC6C5C51B1A4D8BEA261246066858B38184C55FD3DD3D6DFF53F535A64,這裡故意少了開頭一個字元
];
$this->getHttpRequest()->request->add($data);
$notification = new AcceptNotificationRequest($this->getHttpClient(), $this->getHttpRequest());
$notification->initialize([
'HashKey' => '5294y06JbISpM5x9',
'HashIV' => 'v77hoKGq4kWxNNIS',
'EncryptType' => '1',
'MerchantID' => '2000132',
]);
$notification->setTestMode(true);
$notification->getTransactionStatus();
}
補充:這裡的
HashKey/HashIV/MerchantID不是外洩的機敏資料,是綠界官方文件公開提供給所有開發者測試環境使用的固定測試帳號參數。
這個測試沒有測「正常情況」,測的是刻意把簽章弄錯一個字元,確認程式真的會擋下來、而且擋下來的方式是拋出明確的 InvalidRequestException,訊息講清楚是簽章驗證失敗。這種「刻意破壞輸入,驗證系統有沒有正確拒絕」的測試,比十個「正常情況能跑過」的測試更有價值——正常情況本來就該過,例外路徑才是真正容易被漏掉、也最需要被驗證的地方。
一個合理的推測(不是這個套件維護紀錄裡明講的動機,只是觀察現象):CheckMacValue 驗證失敗如果沒擋住,後果是任何人都能偽造一筆付款成功通知,讓系統誤以為訂單已付款——這是資安等級的風險,容易被開發者本能地意識到「這個一定要測」。而 RefundRequest/VoidRequest 目前只測了 Action 欄位,遺漏的邊界情況(金額異常、API 回傳失敗)造成的後果比較間接,也比較容易被視為「以後有空再補」。
測試覆蓋往往不是均勻分布的,而是跟著開發者當下感受到的風險強度走——風險感受得到的地方測得細,風險感受不到或還沒發生過的地方,測試就容易停在「至少測過一次」。
❌ 只用「有沒有寫測試」當品質指標
RefundRequestTest ✓ 有測試
VoidRequestTest ✓ 有測試
AcceptNotificationRequestTest ✓ 有測試
→ 三個類別看起來都「有測試保護」,品質狀態一樣
✅ 追問「測試涵蓋了哪些情境」
RefundRequestTest:1 個測試,只驗證欄位值 → 覆蓋 1 種情境
VoidRequestTest:1 個測試,只驗證欄位值 → 覆蓋 1 種情境
AcceptNotificationRequestTest:3 個測試,含正常流程 + 簽章驗證失敗
→ 覆蓋 2 種以上情境,包含資安關鍵的例外路徑
→ 同樣「有測試」,實際被驗證過的風險範圍差很多
「這個類別有測試」不是一個及格/不及格的二元問題,追問「測試涵蓋了哪些情境」才看得出真正的差距。
你自己專案裡「測得最細」跟「測得最鬆」的兩個模組分別是什麼?测得細的那個,是因為真的重要,還是只是因為出過事、被嚇過一次?
PurchaseRequest 佔 5 個涵蓋 4 種付款方式,Refund/Void 各只有 1 個只測欄位值testInvalidCheckMacValue 是這個套件測得最紮實的部分:刻意破壞簽章,驗證系統正確拋出例外明天看這個套件的測試替身怎麼做——沒有用 Mock/Mockery,而是用繼承覆寫的 Stub 類別,這個選擇背後的取捨是什麼。